/*
 * Copyright 2014-2017 Eduard Ereza Martínez
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 *
 * You may obtain a copy of the License at
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package cat.ereza.customactivityoncrash;

import ohos.aafwk.ability.Ability;
import ohos.aafwk.ability.AbilitySlice;
import ohos.aafwk.content.Intent;
import ohos.aafwk.content.Operation;
import ohos.app.Context;
import ohos.bundle.AbilityInfo;
import ohos.bundle.ApplicationInfo;
import ohos.bundle.BundleInfo;
import ohos.bundle.IBundleManager;
import ohos.data.DatabaseHelper;
import ohos.rpc.RemoteException;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;
import java.io.StringWriter;
import java.lang.ref.WeakReference;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayDeque;
import java.util.Date;
import java.util.Deque;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import cat.ereza.customactivityoncrash.ability.DefaultErrorAbility;
import cat.ereza.customactivityoncrash.config.CaocConfig;

/**
 * CustomActivityOnCrash Controller class
 */
public final class CustomActivityOnCrash {
    private final static String TAG = "CustomActivityOnCrash";

    // Extras passed to the error activity
    private static final String EXTRA_CONFIG = "cat.ereza.customactivityoncrash.EXTRA_CONFIG";

    private static final String EXTRA_STACK_TRACE = "cat.ereza.customactivityoncrash.EXTRA_STACK_TRACE";

    private static final String EXTRA_ACTIVITY_LOG = "cat.ereza.customactivityoncrash.EXTRA_ACTIVITY_LOG";

    // General constants
    private static final String INTENT_ACTION_ERROR_ACTIVITY = "cat.ereza.customactivityoncrash.ERROR";

    private static final String INTENT_ACTION_RESTART_ACTIVITY = "cat.ereza.customactivityoncrash.RESTART";

    private static final String CAOC_HANDLER_PACKAGE_NAME = "cat.ereza.customactivityoncrash";

    private static final int TIME_TO_CONSIDER_FOREGROUND_MS = 500;

    private static final int MAX_STACK_TRACE_SIZE = 131071; // 128 KB - 1

    private static final int MAX_ACTIVITIES_IN_LOG = 50;

    // Shared preferences
    private static final String SHARED_PREFERENCES_FILE = "custom_activity_on_crash";

    private static final String SHARED_PREFERENCES_FIELD_TIMESTAMP = "last_crash_timestamp";

    private static CaocConfig config = new CaocConfig();

    private static final Deque<String> ACTIVITY_LOG = new ArrayDeque<>(MAX_ACTIVITIES_IN_LOG);

    private static WeakReference<Ability> lastActivityCreated = new WeakReference<>(null);

    private static long lastActivityCreatedTimestamp = 0L;

    private static boolean isInBackground = true;

    /**
     * Installs CustomActivityOnCrash on the application using the default error activity.
     *
     * @param context Context to use for obtaining the ApplicationContext. Must not be null.
     */
    public static void install(final Context context) {
        try {
            if (context == null) {
                LogUtil.error(TAG, "Install failed: context is null!");
            } else {
                // INSTALL!
                final Thread.UncaughtExceptionHandler oldHandler = Thread.getDefaultUncaughtExceptionHandler();

                if (oldHandler != null && oldHandler.getClass().getName().startsWith(CAOC_HANDLER_PACKAGE_NAME)) {
                    LogUtil.error(TAG, "CustomActivityOnCrash was already installed, doing nothing!");
                } else {
                    // We define a default exception handler that does what we want so it can be called from Crashlytics/ACRA
                    Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
                        @Override
                        public void uncaughtException(Thread thread, final Throwable throwable) {
                            LogUtil.error(TAG,
                                    "App has crashed, executing CustomActivityOnCrash's UncaughtExceptionHandler"
                                        + throwable);

                            if (config.isEnabled()) {
                                LogUtil.info(TAG, "Config Enabled");
                                if (hasCrashedInTheLastSeconds(context)) {
                                    LogUtil.error(TAG, "App already crashed recently, not starting custom error activity because we could enter a restart loop. Are you sure that your app does not crash directly on init?" + throwable);
                                    if (oldHandler != null) {
                                        oldHandler.uncaughtException(thread, throwable);
                                        return;
                                    }
                                } else {
                                    setLastCrashTimestamp(context, new Date().getTime());

                                    if (isStackTraceLikelyConflictive(throwable)) {
                                        LogUtil.error(TAG, "Your application class or your error activity have crashed, the custom activity will not be launched!");
                                        if (oldHandler != null) {
                                            oldHandler.uncaughtException(thread, throwable);
                                            return;
                                        }
                                    } else if (config.getBackgroundMode() == CaocConfig.BACKGROUND_MODE_SHOW_CUSTOM
                                        || !isInBackground || (lastActivityCreatedTimestamp
                                        >= new Date().getTime() - TIME_TO_CONSIDER_FOREGROUND_MS)) {
                                        final Intent intent = new Intent();
                                        StringWriter sw = new StringWriter();
                                        PrintWriter pw = new PrintWriter(sw);
                                        throwable.printStackTrace(pw);
                                        String stackTraceString = sw.toString();

                                        // Reduce data to 128KB so we don't get a TransactionTooLasrgeException when sending the intent.
                                        // The limit is 1MB on openharmony but some devices seem to have it lower.
                                        if (stackTraceString.length() > MAX_STACK_TRACE_SIZE) {
                                            String disclaimer = " [stack trace too large]";
                                            stackTraceString = stackTraceString.substring(0,
                                                MAX_STACK_TRACE_SIZE - disclaimer.length()) + disclaimer;
                                        }
                                        intent.setParam(EXTRA_STACK_TRACE, stackTraceString);
                                        intent.setParam(EXTRA_CONFIG, config);
                                        intent.setFlags(
                                            Intent.FLAG_ABILITY_NEW_MISSION | Intent.FLAG_ABILITY_CLEAR_MISSION);
                                        if (config.getEventListener() != null) {
                                            config.getEventListener().onLaunchErrorActivity();
                                        }
                                        String errorAbility;
                                        if(config.getErrorAbilityClass() != null){
                                            errorAbility = config.getErrorAbilityClass();
                                        }else{
                                            errorAbility = config.getDefaultErrorAbilityClass();
                                        }
                                        Operation operation = new Intent.OperationBuilder()
                                                .withBundleName(config.getBundle())
                                                .withAbilityName(errorAbility)
                                                .build();
                                        intent.setOperation(operation);
                                        if(context != null) {
                                            LogUtil.info(TAG, "Launch CustomError Activity");
                                            context.startAbility(intent, 0);
                                        }
                                    } else if (config.getBackgroundMode() == CaocConfig.BACKGROUND_MODE_CRASH) {
                                        LogUtil.info(TAG, "BACKGROUND_MODE_CRASH");
                                        if (oldHandler != null) {
                                            LogUtil.info(TAG, "BACKGROUND_MODE_CRASH: OldHandler");
                                            oldHandler.uncaughtException(thread, throwable);
                                            return;
                                        }
                                        // If it is null (should not be), we let it continue and kill the process or it will be stuck
                                    }
                                    // Else (BACKGROUND_MODE_SILENT): do nothing and let the following code kill the process
                                }
                                final Ability lastActivity = lastActivityCreated.get();
                                if (lastActivity != null) {
                                    // We finish the activity, this solves a bug which causes infinite recursion.
                                    lastActivity.terminateAbility();
                                    lastActivityCreated.clear();
                                }
                                killCurrentProcess();
                            } else if (oldHandler != null) {
                                oldHandler.uncaughtException(thread, throwable);
                            }
                        }
                    });
                }
                LogUtil.info(TAG, "CustomActivityOnCrash has been installed.");
            }
        } catch (Exception t) {
            LogUtil.error(TAG,
                "An unknown error occurred while installing CustomActivityOnCrash, it may not have been properly initialized. Please report this as a bug if needed."
                    + t);
        }
    }

    /**
     * Given an Intent, returns the stack trace extra from it.
     *
     * @param intent The Intent. Must not be null.
     * @return The stacktrace, or null if not provided.
     */
    public static String getStackTraceFromIntent(Intent intent) {
        return intent.getStringParam(CustomActivityOnCrash.EXTRA_STACK_TRACE);
    }

    /**
     * Given an Intent, returns the config extra from it.
     *
     * @param intent The Intent. Must not be null.
     * @return The config, or null if not provided.
     */
    public static CaocConfig getConfigFromIntent(Intent intent) {
        CaocConfig config = (CaocConfig) intent.getSerializableParam(CustomActivityOnCrash.EXTRA_CONFIG);
        if (config != null && config.isLogErrorOnRestart()) {
            String stackTrace = getStackTraceFromIntent(intent);
            if (stackTrace != null) {
                LogUtil.error(TAG, "The previous app process crashed. This is the stack trace of the crash:" + System.lineSeparator()
                    + getStackTraceFromIntent(intent));
            }
        }

        return config;
    }

    /**
     * Given an Intent, returns the activity log extra from it.
     *
     * @param intent The Intent. Must not be null.
     * @return The activity log, or null if not provided.
     */
    public static String getActivityLogFromIntent(Intent intent) {
        return intent.getStringParam(CustomActivityOnCrash.EXTRA_ACTIVITY_LOG);
    }

    /**
     * Given an Intent, returns several error details including the stack trace extra from the intent.
     *
     * @param context A valid context. Must not be null.
     * @param intent The Intent. Must not be null.
     * @return The full error details.
     */
    public static String getAllErrorDetailsFromIntent(Context context, Intent intent) {
        // I don't think that this needs localization because it's a development string...

        Date currentDate = new Date();
        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);

        // Get build date
        String buildDateAsString = getBuildDateAsString(context, dateFormat);

        // Get app version
        String versionName = getVersionName(context);

        String errorDetails = "";

        errorDetails += "Build version: " + versionName + " " + System.lineSeparator();
        if (buildDateAsString != null) {
            errorDetails += "Build date: " + buildDateAsString + " " + System.lineSeparator();
        }
        errorDetails += "Current date: " + dateFormat.format(currentDate) + " " + System.lineSeparator();
        errorDetails += "Stack trace:  \n";
        errorDetails += getStackTraceFromIntent(intent);

        String activityLog = getActivityLogFromIntent(intent);

        if (activityLog != null) {
            errorDetails += "\nUser actions: \n";
            errorDetails += activityLog;
        }
        return errorDetails;
    }

    /**
     * Given an Intent, restarts the app and launches a startActivity to that intent.
     * The flags NEW_TASK and CLEAR_TASK are set if the Intent does not have them, to ensure
     * the app stack is fully cleared.
     * If an event listener is provided, the restart app event is invoked.
     * Must only be used from your error activity.
     *
     * @param ability The current error activity. Must not be null.
     * @param intent The Intent. Must not be null.
     * @param config The config object as obtained by calling getConfigFromIntent.
     */
    public static void restartApplicationWithIntent(AbilitySlice ability, Intent intent, CaocConfig config) {
        intent.addFlags(Intent.FLAG_ABILITY_NEW_MISSION | Intent.FLAG_ABILITY_CLEAR_MISSION);
        if (intent.getAction() != null) {
            // If the class name has been set, we force it to simulate a Launcher launch.
            // If we don't do this, if you restart from the error ability, then press home,
            // and then launch the ability from the launcher, the main ability appears twice on the backstack.
            // This will most likely not have any detrimental effect because if you set the Intent component,
            // if will always be launched regardless of the actions specified here.
            intent.setAction(Intent.ACTION_HOME);
            intent.addEntity(Intent.ENTITY_HOME);
        }
        if (config.getEventListener() != null) {
            config.getEventListener().onRestartAppFromErrorActivity();
        }
        ability.terminateAbility();
        ability.startAbility(intent);
        killCurrentProcess();
    }

    /**
     * Restart Application.
     *
     * @param ability The current ability. Must not be null.
     * @param config The config object as obtained by calling getConfigFromIntent.
     */
    public static void restartApplication(AbilitySlice ability, CaocConfig config) {
        Intent intent = new Intent();
        restartApplicationWithIntent(ability, intent, config);
    }

    /**
     * Closes the app.
     * If an event listener is provided, the close app event is invoked.
     * Must only be used from your error activity.
     *
     * @param ability The current error activity. Must not be null.
     * @param config The config object as obtained by calling getConfigFromIntent.
     */
    public static void closeApplication(AbilitySlice ability, CaocConfig config) {
        if (config.getEventListener() != null) {
            config.getEventListener().onCloseAppFromErrorActivity();
        }
        ability.terminateAbility();
        killCurrentProcess();
    }

    // INTERNAL METHODS NOT TO BE USED BY THIRD PARTIES

    /**
     * INTERNAL method that returns the current configuration of the library.
     * If you want to check the config, use CaocConfig.Builder.get();
     *
     * @return the current configuration
     */
    public static CaocConfig getConfig() {
        return config;
    }

    /**
     * INTERNAL method that sets the configuration of the library.
     * You must not use this, use CaocConfig.Builder.apply()
     *
     * @param config the configuration to use
     */
    public static void setConfig(CaocConfig config) {
        CustomActivityOnCrash.config = config;
    }

    /**
     * INTERNAL method that checks if the stack trace that just crashed is conflictive. This is true in the following scenarios:
     * - The application has crashed while initializing (handleBindApplication is in the stack)
     * - The crash occurred inside the "error_activity" process
     *
     * @param throwable The throwable from which the stack trace will be checked
     * @return true if this stack trace is conflictive and the activity must not be launched, false otherwise
     */
    private static boolean isStackTraceLikelyConflictive(Throwable throwable) {
        String process;
        try {
            BufferedReader br = new BufferedReader(new FileReader("/proc/self/cmdline"));
            process = br.readLine().trim();
            br.close();
        } catch (IOException e) {
            process = null;
        }

        if (process != null && process.endsWith(":error_activity")) {
            // Error happened in the error activity process - conflictive, so use default handler
            return true;
        }

        do {
            StackTraceElement[] stackTrace = throwable.getStackTrace();
            for (StackTraceElement element : stackTrace) {
                if (element.getMethodName().equals("handleBindApplication")) {
                    return true;
                }
            }
        } while ((throwable = throwable.getCause()) != null);
        return false;
    }

    /**
     * INTERNAL method that returns the build date of the current APK as a string, or null if unable to determine it.
     *
     * @param context A valid context. Must not be null.
     * @param dateFormat DateFormat to use to convert from Date to String
     * @return The formatted date, or "Unknown" if unable to determine it.
     */
    private static String getBuildDateAsString(Context context, DateFormat dateFormat) {
        long buildDate;
        try {
            ApplicationInfo ai = context.getBundleManager().getApplicationInfo(context.getBundleName(), 0, 0);
            ZipFile zf = new ZipFile(ai.getName());

            // If this failed, try with the old zip method
            ZipEntry ze = zf.getEntry("classes.dex");
            buildDate = ze.getTime();

            zf.close();
        } catch (Exception e) {
            buildDate = 0;
        }

        if (buildDate > 312764400000L) {
            return dateFormat.format(new Date(buildDate));
        } else {
            return null;
        }
    }

    /**
     * INTERNAL method that returns the version name of the current app, or null if unable to determine it.
     *
     * @param context A valid context. Must not be null.
     * @return The version name, or "Unknown if unable to determine it.
     */
    private static String getVersionName(Context context) {
        try {
            BundleInfo bundleInfo = context.getBundleManager().getBundleInfo(context.getBundleName(), 0);
            return bundleInfo.getVersionName();
        } catch (Exception e) {
            return "Unknown";
        }
    }

    /**
     * INTERNAL method that capitalizes the first character of a string
     *
     * @param s The string to capitalize
     * @return The capitalized string
     */
    private static String capitalize(String s) {
        if (s == null || s.length() == 0) {
            return "";
        }
        char first = s.charAt(0);
        if (Character.isUpperCase(first)) {
            return s;
        } else {
            return Character.toUpperCase(first) + s.substring(1);
        }
    }

    /**
     * INTERNAL method used to guess which activity must be called from the error activity to restart the app.
     *
     * @param context A valid context. Must not be null.
     * @return The guessed restart activity class, or null if no suitable one is found
     */
    private static Class<? extends Ability> guessRestartActivityClass(Context context) {
        Class<? extends Ability> resolvedActivityClass;

        // If action is defined, use that
        resolvedActivityClass = getRestartActivityClassWithIntentFilter(context);

        // Else, get the default launcher activity
        if (resolvedActivityClass == null) {
            resolvedActivityClass = getLauncherActivity(context);
        }

        return resolvedActivityClass;
    }

    /**
     * INTERNAL method used to get the first activity with an intent-filter,
     * If there is no activity with that intent filter, this returns null.
     *
     * @param context A valid context. Must not be null.
     * @return A valid activity class, or null if no suitable one is found
     */
    @SuppressWarnings("unchecked")
    private static Class<? extends Ability> getRestartActivityClassWithIntentFilter(Context context) {
        Intent searchedIntent = new Intent().setAction(INTENT_ACTION_RESTART_ACTIVITY)
            .setBundle(context.getBundleName());
        List<AbilityInfo> resolveInfos = null;
        try {
            resolveInfos = context.getBundleManager()
                .queryAbilityByIntent(searchedIntent, IBundleManager.GET_BUNDLE_DEFAULT, 0);
        } catch (RemoteException e) {
            LogUtil.error(TAG,
                "Failed when resolving the restart activity class via intent filter, stack trace follows!" + e);
        }

        if (resolveInfos.size() > 0) {
            AbilityInfo resolveInfo = resolveInfos.get(0);
            try {
                return (Class<? extends Ability>) Class.forName(resolveInfo.getClassName());
            } catch (ClassNotFoundException e) {
                // Should not happen, print it to the log!
                LogUtil.error(TAG,
                    "Failed when resolving the restart activity class via intent filter, stack trace follows!" + e);
            }
        }

        return null;
    }

    /**
     * INTERNAL method used to get the default launcher activity for the app.
     * If there is no launchable activity, this returns null.
     *
     * @param context A valid context. Must not be null.
     * @return A valid activity class, or null if no suitable one is found
     */
    @SuppressWarnings("unchecked")
    private static Class<? extends Ability> getLauncherActivity(Context context) {
        Intent intent = null;
        try {
            intent = context.getBundleManager().getLaunchIntentForBundle(context.getBundleName());
        } catch (RemoteException e) {
            LogUtil.error(TAG,
                "Failed when resolving the restart activity class via getLaunchIntentForPackage, stack trace follows!"
                    + e);
        }
        if (intent != null && intent.getElement() != null) {
            try {
                return (Class<? extends Ability>) Class.forName(intent.getElement().getAbilityName());
            } catch (ClassNotFoundException e) {
                // Should not happen, print it to the log!
                LogUtil.error(TAG,
                    "Failed when resolving the restart activity class via getLaunchIntentForPackage, stack trace follows!"
                        + e);
            }
        }

        return null;
    }

    /**
     * INTERNAL method used to guess which error activity must be called when the app crashes.
     *
     * @param context A valid context. Must not be null.
     * @return The guessed error activity class, or the default error activity if not found
     */
    private static Class<? extends Ability> guessErrorActivityClass(Context context) {
        Class<? extends Ability> resolvedActivityClass;

        // If action is defined, use that
        resolvedActivityClass = getErrorActivityClassWithIntentFilter(context);

        // Else, get the default error activity
        if (resolvedActivityClass == null) {
            resolvedActivityClass = DefaultErrorAbility.class;
        }

        return resolvedActivityClass;
    }

    /**
     * INTERNAL method used to get the first activity with an intent-filter,
     * If there is no activity with that intent filter, this returns null.
     *
     * @param context A valid context. Must not be null.
     * @return A valid activity class, or null if no suitable one is found
     */
    @SuppressWarnings("unchecked")
    private static Class<? extends Ability> getErrorActivityClassWithIntentFilter(Context context) {
        Intent searchedIntent = new Intent().setAction(INTENT_ACTION_ERROR_ACTIVITY).setBundle(context.getBundleName());
        List<AbilityInfo> resolveInfos = null;
        try {
            resolveInfos = context.getBundleManager()
                .queryAbilityByIntent(searchedIntent, IBundleManager.GET_BUNDLE_DEFAULT, 0);
        } catch (RemoteException e) {
            LogUtil.error(TAG, "Failed when resolving the restart activity class via intent filter, stack trace follows!" + e);
        }

        if (resolveInfos.size() > 0) {
            AbilityInfo resolveInfo = resolveInfos.get(0);
            try {
                return (Class<? extends Ability>) Class.forName(resolveInfo.getClassName());
            } catch (ClassNotFoundException e) {
                // Should not happen, print it to the log!
                LogUtil.error(TAG, "Failed when resolving the error activity class via intent filter, stack trace follows!" + e);
            }
        }

        return null;
    }

    /**
     * INTERNAL method that kills the current process.
     * It is used after restarting or killing the app.
     */
    private static void killCurrentProcess() {
        ohos.os.ProcessManager.kill(ohos.os.ProcessManager.getPid());
        System.exit(10);
    }

    /**
     * INTERNAL method that stores the last crash timestamp
     *
     * @param context App context
     * @param timestamp The current timestamp.
     */
    private static void setLastCrashTimestamp(Context context, long timestamp) {
        DatabaseHelper databaseHelper = new DatabaseHelper(context);
        databaseHelper.getPreferences(SHARED_PREFERENCES_FILE).putLong(SHARED_PREFERENCES_FIELD_TIMESTAMP, timestamp);
    }

    /**
     * INTERNAL method that gets the last crash timestamp
     *
     * @param context App context
     * @return The last crash timestamp, or -1 if not set.
     */
    private static long getLastCrashTimestamp(Context context) {
        DatabaseHelper databaseHelper = new DatabaseHelper(context);
        return databaseHelper.getPreferences(SHARED_PREFERENCES_FILE).getLong(SHARED_PREFERENCES_FIELD_TIMESTAMP, -1);
    }

    /**
     * INTERNAL method that tells if the app has crashed in the last seconds.
     * This is used to avoid restart loops.
     *
     * @param context App context
     * @return true if the app has crashed in the last seconds, false otherwise.
     */
    private static boolean hasCrashedInTheLastSeconds(Context context) {
        long lastTimestamp = getLastCrashTimestamp(context);
        long currentTimestamp = new Date().getTime();

        return (lastTimestamp <= currentTimestamp
            && currentTimestamp - lastTimestamp < config.getMinTimeBetweenCrashesMs());
    }

    /**
     * Interface to be called when events occur, so they can be reported
     * by the app as, for example, Google Analytics events.
     */
    public interface EventListener extends Serializable {
        void onLaunchErrorActivity();

        void onRestartAppFromErrorActivity();

        void onCloseAppFromErrorActivity();
    }
}
